Skip to content

fix(rag): stop reasoning-token starvation that discarded answers as "unsupported"#580

Merged
BigSimmo merged 1 commit into
mainfrom
claude/generation-token-starvation-fix
Jul 13, 2026
Merged

fix(rag): stop reasoning-token starvation that discarded answers as "unsupported"#580
BigSimmo merged 1 commit into
mainfrom
claude/generation-token-starvation-fix

Conversation

@BigSimmo

Copy link
Copy Markdown
Owner

Summary

Production answers were degrading to "unsupported" after 60–90s on high-value clinical queries. Root cause (confirmed via live rag_queries telemetry): gpt-5.5 draws reasoning tokens from the same max_output_tokens budget as the visible answer. The strong route ran high reasoning effort against a 4000-token cap, so reasoning exhausted the budget and returned incomplete: max_output_tokens before writing the answer — the answer was discarded. The same high effort also overran the 30s timeout (provider_timeout). It's a class of bug: every reasoning-model call sized its token budget without reserving reasoning headroom, including the ingestion enrichment/extraction paths that ran silently over the whole corpus.

  • env: OPENAI_MAX_OUTPUT_TOKENS 4000→16000; OPENAI_STRONG_REASONING_EFFORT high→medium. medium resolves every query class — including the safety-critical medication_dose_risk/table_threshold, which strongReasoningEffortForQueryClass kept at the full configured effort and which starved first.
  • openai: reasoningHeadroomFloor(effort) floors max_output_tokens by effort in responseBody, so no call site can under-provision reasoning headroom. Math.max only ever raises a budget, and the budget is a ceiling (billed per token used) — free unless hit. One change fixes every call site.
  • rag: truncation self-heal — strong escalations retry at a boosted cap (2×, ≥24000) instead of re-truncating on the same budget; a cumulative generation wall-clock budget skips the third (polish) generation to bound tail latency, keeping the valid strong answer rather than risking a truncation→unsupported tail.
  • ingestion: document-enrichment + model-index-extraction now observe truncation and warn loudly (with document identity) instead of silently cutting off enrichment.
  • observability: answer-slo adds truncation/timeout fallback counters to the /api/health deep probe.

Host/region change (the separate cross-region retrieval latency) is intentionally out of scope.

Verification

  • npm run verify:cheap — green (1673 tests, 1 skipped, 179 files) + typecheck + prettier clean. (Full verify:pr-local build/bundle scan left to CI.)
  • npm run eval:retrieval:quality — 36/36, document_recall@5=1.0, content_recall@5=1.0, content_mrr@10=0.9414 (≥ 0.9255 baseline). Retrieval untouched (change is generation-only).
  • npm run eval:quality -- --rag-only (live) — the max_output_tokens and provider_timeout fallbacks are eliminated. Ran an A/B baseline (pre-fix vs fix on the same live corpus): every grounding/citation/failure metric is byte-identical (grounded 0.4667=0.4667, citation_failure 0.2955=0.2955, failure_category_counts identical, zero grounded cases lost) → zero quality regression, plus a ~14s p95 latency win (75.9s→61.5s) and one timeout fallback removed. The grounded-rate threshold miss is proven pre-existing (documentation-lookup confidence-gate cases, e.g. discharge-documentation).
  • npm run check:production-readinessnot run (provider-backed / confirmation-gated per repo policy). No clinical-decision, privacy, Supabase, or source-governance behavior changed by this PR.

Clinical Governance Preflight

  • Source-backed claims still require linked source verification before clinical use — unchanged
  • No patient-identifiable document workflow was introduced or expanded
  • Supabase target remains Clinical KB Database (sjrfecxgysukkwxsowpy) — untouched
  • Service-role keys and private document access remain server-only — untouched
  • Demo/synthetic content remains clearly separated from real clinical sources — untouched
  • Source metadata, review status, and outdated/unknown-source behavior remain conservative — untouched
  • Deployment classification/TGA SaMD impact checked — no change to clinical decision-support behavior: the A/B baseline shows answer content/grounding is byte-identical; this PR only prevents token-starvation truncation and lowers latency.

Notes

  • Change was developed off a diverged branch; this PR is a clean re-application of the 10-file change onto current main (only tests/answer-slo.test.ts needed a trivial merge with main's new event_type scoping — the truncation/timeout counters inherit that filter via the shared base()).

🤖 Generated with Claude Code

…unsupported"

gpt-5.5 draws reasoning tokens from the SAME max_output_tokens budget as the visible
answer. The strong route ran high reasoning effort against a 4000-token cap, so
reasoning exhausted the budget and returned `incomplete: max_output_tokens` before
writing the answer — the answer was then discarded and the request degraded to
"unsupported" after 60-90s (confirmed via live rag_queries telemetry). The same high
effort also overran the 30s answer timeout (provider_timeout). It is a class of bug:
every reasoning-model call sized max_output_tokens without reserving reasoning headroom,
including the ingestion enrichment/extraction paths that ran silently over the corpus.

- env: OPENAI_MAX_OUTPUT_TOKENS 4000->16000; OPENAI_STRONG_REASONING_EFFORT high->medium.
  medium resolves every query class, including the safety-critical medication_dose_risk
  and table_threshold that strongReasoningEffortForQueryClass kept at the full configured
  effort and that starved first.
- openai: reasoningHeadroomFloor(effort) floors max_output_tokens by effort in
  responseBody so no call site can under-provision reasoning headroom. Math.max only ever
  raises a budget, and the budget is a ceiling (billed per token used), so it is free.
- rag: truncation self-heal — strong escalations retry at a boosted cap (2x, >=24000)
  instead of re-truncating on the same budget; a cumulative generation wall-clock budget
  skips the third (polish) generation to bound tail latency, keeping the valid strong
  answer rather than risking a truncation -> unsupported tail.
- ingestion: document-enrichment and model-index-extraction now observe truncation and
  warn loudly (with document identity) instead of silently cutting off enrichment.
- observability: answer-slo adds truncation/timeout fallback counters to the
  /api/health deep probe so this regression class is visible without hand-running SQL.

Verified: verify:cheap (1673 tests) + typecheck + prettier offline; live golden retrieval
36/36 (content_mrr@10 0.9414); live eval:quality --rag-only eliminated the
max_output_tokens and provider_timeout fallbacks. An A/B baseline (pre-fix vs fix on the
same live corpus) is byte-identical on every grounding/citation/failure metric (zero
quality regression) and cuts p95 generation latency ~14s (75.9s -> 61.5s).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@supabase

supabase Bot commented Jul 13, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project sjrfecxgysukkwxsowpy because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@BigSimmo
BigSimmo enabled auto-merge (squash) July 13, 2026 12:22
@BigSimmo
BigSimmo merged commit ccf7334 into main Jul 13, 2026
14 of 15 checks passed
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 678dec22-81f3-4670-8f61-a9f34e7e6135

📥 Commits

Reviewing files that changed from the base of the PR and between 7ecade0 and 5874814.

📒 Files selected for processing (10)
  • src/lib/document-enrichment.ts
  • src/lib/env.ts
  • src/lib/model-index-extraction.ts
  • src/lib/observability/answer-slo.ts
  • src/lib/openai.ts
  • src/lib/rag.ts
  • tests/answer-slo.test.ts
  • tests/document-enrichment.test.ts
  • tests/model-index-extraction.test.ts
  • tests/openai-cache.test.ts

📝 Walkthrough

Summary by CodeRabbit

  • Improvements
    • Improved AI-generated answers by providing additional output capacity for complex reasoning.
    • Enhanced recovery when responses are incomplete, including smarter retries and safeguards against excessive retry delays.
    • Improved document enrichment and model-generated profiles when AI output is partially incomplete.
    • Added more detailed monitoring for answers affected by output limits or timeouts.
  • Bug Fixes
    • Reduced cases where reasoning consumed too much of the available answer space.

Walkthrough

Generation now preserves structured-result metadata, warns on truncation, reserves reasoning headroom, expands strong retry budgets, limits cumulative retry time, and tracks truncation and timeout fallbacks in answer SLO metrics.

Changes

Generation resilience

Layer / File(s) Summary
Token headroom defaults and request flooring
src/lib/env.ts, src/lib/openai.ts, tests/openai-cache.test.ts
Generation defaults, reasoning effort, and OpenAI request budgets now apply reasoning headroom flooring with corresponding tests.
Strong retry budgeting
src/lib/rag.ts
Strong retries can use larger output-token budgets and are skipped when the cumulative generation time budget is exhausted.
Structured output truncation handling
src/lib/document-enrichment.ts, src/lib/model-index-extraction.ts, tests/document-enrichment.test.ts, tests/model-index-extraction.test.ts
Structured generation consumers inspect result metadata, warn on truncation, and parse result.text; mocks now provide object-shaped results.
Fallback SLO dimensions
src/lib/observability/answer-slo.ts, tests/answer-slo.test.ts
Answer SLO snapshots now count and rate truncation and timeout fallback cases using additional PostgREST filters.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant generateWithModel
  participant OpenAIResponsesAPI
  participant strongQualityRepair
  generateWithModel->>OpenAIResponsesAPI: Request structured output
  OpenAIResponsesAPI-->>generateWithModel: Return text and truncation status
  generateWithModel->>strongQualityRepair: Evaluate answer quality
  strongQualityRepair->>OpenAIResponsesAPI: Retry with larger output budget
  OpenAIResponsesAPI-->>strongQualityRepair: Return retry result
Loading

Possibly related PRs

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/generation-token-starvation-fix
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch claude/generation-token-starvation-fix

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5874814cd3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/lib/rag.ts
: null;
const answerNeedsStrongQualityRepair = usedStrongModel && Boolean(strongQualityFailureReason);
if (answerNeedsStrongQualityRepair) {
if (answerNeedsStrongQualityRepair && generationLatencyMs >= generationTotalBudgetMs) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reserve time before starting the quality retry

When the fast/strong path has already spent just under 2 * OPENAI_ANSWER_TIMEOUT_MS, this >= check still allows the strong_quality_retry below to start with a full per-call timeout, so two 29s generations under the default 30s timeout can still kick off a third 30s call and recreate the ~90s tail this guard is meant to prevent. Reserve the next call's timeout in this guard, or pass only the remaining budget into the retry, so the new wall-clock budget actually bounds the chain.

Useful? React with 👍 / 👎.

BigSimmo added a commit that referenced this pull request Jul 13, 2026
… baseline (#622)

* fix(eval): recalibrate canary latency budgets to the post-#606 honest baseline

The pre-2026-07-13 budgets (p95 25s overall, 12s extractive) were calibrated
while confidence_gate_blocked refusals dominated the answer subset - refusals
return in ~1-2s, so the canary was timing fast wrong answers. With #606
restoring grounded answering, p95 reflects real work: the fast->extractive
fallback chain spends the full 30s answer timeout before stitching sources,
and #580's strong truncation self-heal can run two sequential generations.
Post-fix observed subset p95 is 48.3s with every quality metric green
(grounded 1.0, citation failure 0, numeric failure 0).

New budgets catch regressions from that baseline instead of re-flagging the
fix: overall 60s, extractive 50s, strong 60s. Fast (25s) and unsupported (4s)
are unchanged - refusals must stay fast, which is exactly the waste mode #580
eliminated. These are eval-runner budgets (GitHub-hosted, cross-region to
Sydney), not production UX targets; production SLOs stay in answer-slo.ts and
docs/observability-slos.md. Closes the remaining red on #459.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(eval): bucket generation-fallback chains separately; sync reindex gate ceiling

Review follow-ups on the latency recalibration:

- latencyRouteForAnswer now classifies answers whose routing reason records a
  generation_fallback into a dedicated "fallback" latency bucket (50s budget):
  a failed generation structurally costs its timeout plus the fallback work.
  This restores the tight budgets the first commit accidentally loosened or
  missed: extractive returns to 12s (no-model stitching must stay fast) and
  plain fast stays at 25s, so regressions on either path cannot hide inside
  the fallback allowance.
- reindex-eval-gate quality p95 ceiling 25s -> 60s to match the canary budget
  (a no-regression reindex at the honest ~48s baseline must not be blocked by
  the absolute bar); baseline-relative regression detection via
  latencyRegressionMs is unchanged. Runbook updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(eval): classify generation fallbacks before strong-route reasons

A strong-classified reason carrying a generation_fallback (e.g.
multi_document_comparison_synthesis; generation_fallback:provider_timeout)
must budget as a fallback chain, not blend into the strong budget; successful
strong generations (including quality retries) never record a
generation_fallback, so genuine strong answers are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@BigSimmo
BigSimmo deleted the claude/generation-token-starvation-fix branch July 14, 2026 09:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant